Amplify Gen2でNextJSのアプリケーション作成まで
昨年発表されたAmplify Gen2(プレビュー)ですが、日々機能が少しづつ追加されており、完成度が高まってきました。 今回はチュートリアルに従ってNextJSを構築し、動作を確認してみます。
Amplify Gen2と、従来のAmplify
従来のAmplifyは「ツールファースト」と呼ばれています。豊富なメニューが用意されており、メニューを選択するだけで、必要な機能を構築することができました。 一方、メニューで選択できない部分の修正の難易度が高かったり、メニューや内容を把握する、「Amplifyならではの知識」が必要となる、というポイントがありました。
新しいAmplify Gen2は「コードファースト」です。TypeScript等の言語で、CDKをベースとした構築を行うスタイルに変わりました。これにより、CDKの知識は必要なものの、自由度が増し、開発者がIaCの細部まで手を入れることが出来る、というスタイルに変化しています。
構築
公開されていたチュートリアルの構築はよく出来ており、手順に従えば簡単にTodoアプリを構築できます。
なお、Amplify gen2はプレビュー版の為、従来のAmplifyほどに機能が充実しておらず、順次増えていく想定です。(Storage機能もほんの最近追加されました) 今回はチュートリアルに従い、Next.js App Router (Server Components)の構築を行いつつ、ポイントを解説してみました。
プロジェクトの作成
npm create amplify@beta
構築を行うと、auth(Cognito)、data(AppSync)がデフォルトで提供されていることが分かります。
ローカルサーバの起動
npx amplify sandbox --name hogehoge
フロントエンドの起動に加え、sandboxという、開発者個々に割当が可能な、AWS上のクラウド環境を起動します。これにより、同じAWSアカウント上で複数の開発者が開発しても、Cognitoやlambdaのコンフリクトを防ぐことができます。
実行する場合、---nameオプションで識別名を付与することをお勧めします(lambdaの関数名等に反映され、関数の特定が容易になります)。
バックエンドを構築する
AmplifyはGraphQLを推しています。以前と比べ、直感的にスキーマを定義できるようになっています。 a.model()を使うことで、GET、PUT、UPDATE、DELETE、LIST、サブスクリプション機能を追加できます。 authorization()やidentifier()、secondaryIndexes()をチェインすることで、R/W権限や、デフォルトのデータソースであるDynamoDBのPrimary Key,Sort Key、Secondery Indexの指定ができます。
なお、model()で得られるLIST機能は、内部でDynamoDBへのスキャンを行ってしまうため、indexを効かせたQueryを実行したい場合、カスタムクエリでリゾルバを作成する必要がありそうです(後述します)。
const schema = a.schema({ Todo: a .model({ content: a.string(), done: a.boolean(), priority: a.enum(['low', 'medium', 'high']) }) .authorization([a.allow.owner(), a.allow.public().to(['read'])]), });
認証の追加
Cognitoの設定を記述します。 Amplify Gen2は、ログイン方法にusernameを選ぶことが出来ず、emailか、phoneNumberからの選択となるようです(CDKのL1コンストラクトから書き換えを試してみましたが、不許可のエラーとなってしまいました)。
import { defineAuth } from '@aws-amplify/backend'; export const auth = defineAuth({ loginWith: { email: { verificationEmailSubject: 'Welcome! Verify your email!' }, } });
UIの構築
Amplify クライアント側の構成 <ConfigureAmplifyClientSide /> コンポーネントを挟むことで、クライアント側でamplifyのAPIの実行を可能にしています.
ログインコンポーネント
クライアントサイドで認証をチェックし、認証時はホーム画面、 未認証時はログイン画面を表示するようにしています。
サーバー側リダイレクト用のミドルウェアの追加
NextJSのmiddrewareの機能を使用して、ページ遷移時にサーバ側で認証をチェックすることにより、認証のないユーザーをloginページに遷移させています。
To Do アイテムのリストを表示する
dataで作成したTodoモデルを元に、listを呼び出し、DynamoDBに格納されたデータを AppSync経由で取り出しています。
const { data: todos } = await cookiesClient.models.Todo.list();
新しい To Do アイテムを作成する
formに格納されたデータがSubmitに対して、Server Actionを直接割り当て、ToDoを作成します。 revalidatePathにより、コンポーネントの再検証が行われ、最新のデータが表示される仕組みです。
async function addTodo(data: FormData) { "use server"; const title = data.get("title") as string; await cookiesClient.models.Todo.create({ content: title, done: false, priority: "medium", }); revalidatePath("/"); }
カスタムクエリ・ミューテーションを設定する
チュートリアルから外れて、カスタムクエリを実行できるように改修を行ってみます。
前述の通り、model()によるLIST機能はScanによるものなので、Indexを効かせてQueryを発行したり、Mutationに合わせて通知を発行したりしたい場合、appSyncリゾルバやLambdaによるリゾルバを設定する必要があります。 なお、パイプラインリゾルバも設定可能です。
今回は、スキーマ定義を変更して、複合キーを貼り、createDateをbegins_withで絞り込んで取得してみます。
Todo型を変更し、tagとcreateDateの複合キーに変更します。 次に、カスタムクエリ用の型と、リゾルバに対するリクエスト・レスポンスを定義します。 TodosをArrayで返却するのがポイントです。
const schema = a.schema({ Todo: a .model({ tag: a.string().required(), content: a.string(), done: a.boolean(), createDate: a.string().required(), priority: a.enum(["low", "medium", "high"]), }) .identifier(["tag", "createDate"]) .authorization([a.allow.owner()]), Todos: a.customType({ tag: a.string().required(), content: a.string(), done: a.boolean(), createDate: a.string().required(), priority: a.enum(["low", "medium", "high"]), }), RangeTodos: a .query() .arguments({ tag: a.string().required(), year: a.string().required(), }) .returns(a.ref("Todos").array()) .authorization([a.allow.private()]) .handler([ a.handler.custom({ dataSource: a.ref("Todo"), entry: "./appsyncResolver.js", }), ]), });
リゾルバは、関数ではなく、AppSyncリゾルバを使用しました。
export function request(ctx) { if (!ctx.identity) { runtime.earlyReturn([]); } return { operation: "Query", query: { expression: "tag = :tag and begins_with(createDate, :date)", expressionValues: { ":tag": { S: ctx.arguments.tag }, ":date": { S: ctx.arguments.date }, }, }, }; } export function response(ctx) { return ctx.result.items; }
page.tsxを、クエリパラメータを使用し、rangeTodoListからデータを取得するように修正します。 日付の入力部分もServer Actionで定義します。
import { revalidatePath } from "next/cache"; import { AuthGetCurrentUserServer, cookiesClient } from "@/utils/amplify-utils"; import Logout from "@/components/Logout"; import { redirect } from "next/navigation"; async function App({ searchParams, }: { searchParams?: { [key: string]: string }; }) { const user = await AuthGetCurrentUserServer(); const dateParam = !searchParams?.date ? "20" : (searchParams?.date as string); const { data: rangeTodoList } = (await cookiesClient.queries.RangeTodos({ tag: "test", date: dateParam, })) ?? []; async function addTodo(data: FormData) { "use server"; await cookiesClient.models.Todo.create({ tag: "test", content: data.get("title") as string, done: false, priority: "medium", createDate: data.get("createDate") as string, }); revalidatePath("/"); } async function rangeTodo(data: FormData) { "use server"; const dateParam = data.get("date"); redirect("/" + dateParam ? `?date=${data.get("date")}` : ""); } return ( <> <h1>Hello, Amplify 👋</h1> {user && <Logout />} <form action={rangeTodo}> <input type="text" name="date" /> <button type="submit">Range Todo</button> </form> <form action={addTodo}> <input type="date" name="createDate" /> <input type="text" name="title" /> <button type="submit">Add Todo</button> </form> <ul> {rangeTodoList!.map((rangeTodo) => ( <li key={rangeTodo!.tag + rangeTodo!.createDate}> {rangeTodo!.createDate} : {rangeTodo!.content} </li> ))} </ul> </> ); } export default App;
通常の表示
「2023」で絞り込んで表示
begins_withによる前方一致の絞り込みが機能していることが分かります。 また、CDKでApp Syncのリゾルバを定義する場合に比べ、比較的に簡単に構築を行うことができました。
その他、構築で気になった点
今回のチュートリアルでは触れていない点となりますが、Amplify Gen2のdataでも、元のAppSyncの機能である、リアルタイムな購読を行うことができます。ただし、購読はClient Componentで実行する必要がありました。
まとめ
Amplify Gen2の構築のチュートリアルに従い、Next.js App RouterへのAmplifyの組み込みのポイントを確認してみました。
次回は、Amplify Gen2のホスティングを使用し、デプロイの解説を行います。